通過禁止比較讓 Go 二進位文件變小
大家常規的認知是,Go 程序中聲明的類型越多,生成的二進位文件就越大。這個符合直覺,畢竟如果你寫的代碼不去操作定義的類型,那麼定義一堆類型就沒有意義了。然而,鏈接器的部分工作就是檢測沒有被程序引用的函數(比如說它們是一個庫的一部分,其中只有一個子集的功能被使用),然後把它們從最後的編譯產出中刪除。常言道,「類型越多,二進位文件越大」,對於多數 Go 程序還是正確的。
本文中我會深入講解在 Go 程序的上下文中「相等」的意義,以及為什麼像這樣的修改會對 Go 程序的大小有重大的影響。
定義兩個值相等
Go 的語法定義了「賦值」和「相等」的概念。賦值是把一個值賦給一個標識符的行為。並不是所有聲明的標識符都可以被賦值,如常量和函數就不可以。相等是通過檢查標識符的內容是否相等來比較兩個標識符的行為。
作為強類型語言,「相同」的概念從根源上被植入標識符的類型中。兩個標識符只有是相同類型的前提下,才有可能相同。除此之外,值的類型定義了如何比較該類型的兩個值。
例如,整型是用算數方法進行比較的。對於指針類型,是否相等是指它們指向的地址是否相同。映射和通道等引用類型,跟指針類似,如果它們指向相同的地址,那麼就認為它們是相同的。
上面都是按位比較相等的例子,即值佔用的內存的位模式是相同的,那麼這些值就相等。這就是所謂的 memcmp,即內存比較,相等是通過比較兩個內存區域的內容來定義的。
記住這個思路,我過會兒再來談。
結構體相等
除了整型、浮點型和指針等標量類型,還有複合類型:結構體。所有的結構體以程序中的順序被排列在內存中。因此下面這個聲明:
type S struct {
a, b, c, d int64
}
會佔用 32 位元組的內存空間;a
佔用 8 個位元組,b
佔用 8 個位元組,以此類推。Go 的規則說如果結構體所有的欄位都是可以比較的,那麼結構體的值就是可以比較的。因此如果兩個結構體所有的欄位都相等,那麼它們就相等。
a := S{1, 2, 3, 4}
b := S{1, 2, 3, 4}
fmt.Println(a == b) // 輸出 true
編譯器在底層使用 memcmp 來比較 a
的 32 個位元組和 b
的 32 個位元組。
填充和對齊
然而,在下面的場景下過分簡單化的按位比較的策略會返回錯誤的結果:
type S struct {
a byte
b uint64
c int16
d uint32
}
func main()
a := S{1, 2, 3, 4}
b := S{1, 2, 3, 4}
fmt.Println(a == b) // 輸出 true
}
編譯代碼後,這個比較表達式的結果還是 true
,但是編譯器在底層並不能僅依賴比較 a
和 b
的位模式,因為結構體有填充。
Go 要求結構體的所有欄位都對齊。2 位元組的值必須從偶數地址開始,4 位元組的值必須從 4 的倍數地址開始,以此類推 1 。編譯器根據欄位的類型和底層平台加入了填充來確保欄位都對齊。在填充之後,編譯器實際上看到的是 2 :
type S struct {
a byte
_ [7]byte // 填充
b uint64
c int16
_ [2]int16 // 填充
d uint32
}
填充的存在保證了欄位正確對齊,而填充確實佔用了內存空間,但是填充位元組的內容是未知的。你可能會認為在 Go 中 填充位元組都是 0,但實際上並不是 — 填充位元組的內容是未定義的。由於它們並不是被定義為某個確定的值,因此按位比較會因為分布在 s
的 24 位元組中的 9 個填充位元組不一樣而返回錯誤結果。
Go 通過生成所謂的相等函數來解決這個問題。在這個例子中,s
的相等函數只比較函數中的欄位略過填充部分,這樣就能正確比較類型 s
的兩個值。
類型演算法
呵,這是個很大的設置,說明了為什麼,對於 Go 程序中定義的每種類型,編譯器都會生成幾個支持函數,編譯器內部把它們稱作類型的演算法。如果類型是一個映射的鍵,那麼除相等函數外,編譯器還會生成一個哈希函數。為了維持穩定,哈希函數在計算結果時也會像相等函數一樣考慮諸如填充等因素。
憑直覺判斷編譯器什麼時候生成這些函數實際上很難,有時並不明顯,(因為)這超出了你的預期,而且鏈接器也很難消除沒有被使用的函數,因為反射往往導致鏈接器在裁剪類型時變得更保守。
通過禁止比較來減小二進位文件的大小
現在,我們來解釋一下 Brad 的修改。向類型添加一個不可比較的欄位 3 ,結構體也隨之變成不可比較的,從而強制編譯器不再生成相等函數和哈希函數,規避了鏈接器對那些類型的消除,在實際應用中減小了生成的二進位文件的大小。作為這項技術的一個例子,下面的程序:
package main
import "fmt"
func main() {
type t struct {
// _ [0][]byte // 取消注釋以阻止比較
a byte
b uint16
c int32
d uint64
}
var a t
fmt.Println(a)
}
用 Go 1.14.2(darwin/amd64)編譯,大小從 2174088 降到了 2174056,節省了 32 位元組。單獨看節省的這 32 位元組似乎微不足道,但是考慮到你的程序中每個類型及其傳遞閉包都會生成相等和哈希函數,還有它們的依賴,這些函數的大小隨類型大小和複雜度的不同而不同,禁止它們會大大減小最終的二進位文件的大小,效果比之前使用 -ldflags="-s -w"
還要好。
最後總結一下,如果你不想把類型定義為可比較的,可以在源碼層級強制實現像這樣的奇技淫巧,會使生成的二進位文件變小。
附錄:在 Brad 的推動下,Cherry Zhang 和 Keith Randall 已經在 Go 1.15 做了大量的改進,修復了最嚴重的故障,消除了無用的相等和哈希函數(雖然我猜想這也是為了避免這類 CL 的擴散)。
相關文章:
-
[Go 運行時如何高效地實現映射(不使用泛型)](https://dave.cheney.net/2018/05/29/how-the-go-runtime-implements-maps-efficiently-without-generics "How the Go runtime implements maps efficiently (without generics)")
-
[空結構體](https://dave.cheney.net/2014/03/25/the-empty-struct "The empty struct")
-
[填充很難](https://dave.cheney.net/2015/10/09/padding-is-hard "Padding is hard")
-
[Go 中有類型的 nil(2)](https://dave.cheney.net/2017/08/09/typed-nils-in-go-2 "Typed nils in Go 2")
-
在 32 位平台上
int64
和unit64
的值可能不是按 8 位元組對齊的,因為平台原生的是以 4 位元組對齊的。查看 議題 599 了解內部詳細信息。 ↩ -
32 位平台會在
a
和b
的聲明中填充_ [3]byte
。參見前一條。 ↩ -
Brad 使用的是
[0]func()
,但是所有能限制和禁止比較的類型都可以。添加了一個有 0 個元素的數組的聲明後,結構體的大小和對齊不會受影響。 ↩
via: https://dave.cheney.net/2020/05/09/ensmallening-go-binaries-by-prohibiting-comparisons
作者:Dave Cheney 選題:lujun9972 譯者:lxbwolf 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive